GCC Basic

想学好编程C/C++应该是个避不开的,因为几乎所有重要的程序的底层都是用C/C++写的,而绝大多数的开源C/C++都是用GCC编译下 Linux 运行环境中,所以掌握 GCC 的用法是十分必要的,了解 GCC 在 Linux 下编译程序的步骤是非常有必要的。

GCC 是 GNU Compiler Collection 的缩写,这个编译器套件支持多种语言,最著名就是 C 和 C++ 了。别的一些语言像 Java 用的肯定就少了,但是它也支持,还支持 Fortan、Objcect-C、Ada 语言。除了 GCC 之外,GNU 定义一套 GNU Toolchain 来开发应用和操作系统,这些都是非常有必要去了解的,比如 Autotools 中的 Automake、Autoconf、Autohead 就在程序跨平台编译中起到重要作用,我观察在开源的项目中有不少应用到此工具。gdb(GNU Debugger)用来调试 C/C++ 程序非常有用,可以打断点、看到堆栈信息以及局部变量的值,跟在 IDE 中使用调试工具没什么两样,这样就在 print 的基础上多一个有力的工具。而 gdb 的用处还不仅仅在于此,gdb 可以看到 CoreDump 的堆栈信息,能够在程序崩溃时定位到具体的问题,我就因此受益了,前段时间我的棋牌服务器程序总是莫名宕机,通过 gdb 查看 CoreDump 文件的信息发现了是缓冲区溢出导致的。GNU Make 这个不用多说,几乎任何 Linux 下的项目都会手写或者生成一个 Makefile 文件,前面的 Automake 生成的也是 Makefile 文件。还有 GNU Binutils 包括链接(ld)、装载和汇编器这些重要工具。以及 GNU Bison 一个词法生成器,还有就是 m4 通用宏语言。

可以看出整个这一套工具围绕都是怎样编译程序最终生成一个可执行的文件,这套工具链构建了几乎整个 Linux 大厦,并且繁荣了 C/C++ 几十年之久。我很想学好 C/C++ 语言,投资这套东西绝对是有益的。

GCC 本身是一个跨平台的程序,除了 Linux 下的,在 Macos 中有 clang 变种,在 Windows 下的 Cygwin 和 MinGW(Minimalist GNU for Windows) 都有对应的移植项目。不同平台 GCC 如何安装和不同特性仔细参考对应的文档即可。

学习一个东西最重要的怎样查看文档,当了解基本用法之后就需要自己去完善知识了,任何一个严肃的程序都会有完整翔实的文档,而它可以写的像一本书那么厚,学过程序的都知道的。GCC 查看帮助最简单的方式就是 gcc --help 会展示最常用选项以及具体的含义,可以在极短时间内获得信息。gcc --version 获得 GCC 的版本信息,gcc -vgcc --verbose 的缩写。要想获得更为完备的帮助信息必须查看 man 文件,man gcc | col -b > gcc.txt 将 man 文档中的 \b (reverse (and half reverse) line feeds)或者叫 backspace ,写入到文件中,可以更加方便的查看。

GCC 最重要的几个选项

在用 GCC 编译程序时常用的用法是这样的 gcc -o hello -Wall -g -O2 hello.c,以下会一一解释:

以上这些选项是几乎任何编译都要用到的选项。如果有多个输入文件,就在 hello.c 的后面继续添加即可,头文件是不需要指定的,编译自己会查找并用 cpp 程序进行预处理,输入文件除了可以是 .c 文件外还可以是 .o 文件。在大型项目中,通用的步骤是先生成所有 .o 文件,再将这些 .o 文件链接起来形成可执行文件。稍微大一点的项目所需要的选项就会比上面的多。其中指定查找头文件的目录(-I),指定查找链入库的目录(-L),指定库的名字(-l)是最重要的几个。

当开发一个项目往往涉及到很多的头文件,甚至包括第三方库的头文件,此时需要在编译时告知编译器头文件的位置,这时就需要用到 -I 参数。GCC 内置了一些查找位置,调用 cpp -v 即可查看,说到底 #include 头文件本身就是给预处理器使用的,而链入库则是给链接器使用的。在我的 Ubuntu 16.04 机器上显示的默认头文件查找位置如下:

#include <...> search starts here:
/usr/lib/gcc/x86_64-linux-gnu/5/include
/usr/local/include
/usr/lib/gcc/x86_64-linux-gnu/5/include-fixed
/usr/include/x86_64-linux-gnu
/usr/include

除此之外 GCC 还允许通过 CPATH 环境变量指定头文件搜索路径。在这几个指定的路径中,-I > CPATH > default-include-path 的优先级关系,所以会最优先查找 -I 指定的位置,找不到再去其它路径中找。

-L 和 -l 是链接器 ld 用到的参数,用来寻找链接的库,链接的库可以是静态库也可以是动态库。库其实也是通过 GCC 编译而成的,跟 .o 文件格式差不多,所以可以链接到你的代码中去。静态链接是通过 ar 程序打包 .o 文件而成,链接时将库中的代码和符号直接拷贝到程序中去。而 so 文件中地址是位置无关的(PIC的含义 Position Indepent Code),链接这种库仅仅在程序中创建一个符号表,当运行程序时,链接器还要负责将动态库载入到程序中,并且将地址位置调整为正确的值,这也是动态链接(dynamic linking)的由来。动态链接的好处在于操作系统只载入一次动态库就可以为所有用到此动态库的程序使用,节省了内存,而且由于程序不再需要拷贝代码,使得程序的体积变小了。并且动态库升级不需要重新编译程序。虽然有这么多的好处,不得不说是动态库比静态库复杂,你如果听过 Windows dll hell 就明白其中的复杂度了,管理动态库本身就是一件不容易的事。

GCC 默认链接动态库。

GCC 内置了默认的库查找路径,ld --verbose | grep SEARCH_DIR 或者 gcc -v hello.c 可以看到默认的查找库路径,ld 使用 Linker Script 来定制链接过程,系统默认的脚本中包含了搜索路径。同时编译链接可以用环境变量 LIBRARY_PATH 指定,-L > LIBRARY_PATH > default-library-path 的优先级顺序。GCC 还要求指定用到的库的名字,-lxxx 中的库名字xxx,在 Unix 下将查找 libxxx.a 或者 libxxx.so 文件。在 Windows 下必须指定全名。

动态链接库

动态链接库比静态链接库复杂,多了一个载入 (Load) 的过程。编译动态链接库分为两步,我以简单的示例代码为例:

calc_mean.h

#ifndef calc_mean_h__
#define calc_mean_h__
double mean(double, double);
#endif  // calc_mean_h__

calc_mean.c

#include "calc_mean.h"
double mean(double a, double b)
{
    return (a+b) / 2;
}

编译以上代码分为两步:-fPIC 生成位置独立代码,-shared 链接成动态库。

gcc -c -fPIC -o calc_mean.o calc_mean.c
gcc -shared -o libcalc_mean.so calc_mean.o

或合并成一条命令:

gcc -fPIC -shared -o libcalc_mean.so calc_mean.c

我们写一个简单 main 文件来使用这个动态库。
main.c

#include <stdio.h>
#include "calc_mean.h"

int main(int argc, char* argv[]) {
    double v1, v2, m;
    v1 = 5.2;
    v2 = 7.9;

    m  = mean(v1, v2);

    printf("The mean of %3.2f and %3.2f is %3.2f\n", v1, v2, m);

    return 0;
}

为了编译这个文件我们得使用以下命令,-L. 指定搜索路径为当前目录,-lcalc_mean 指定用到的库。

gcc -o test main.c -lcalc_mean -L.

这样就生成了可执行文件 test,但是现在执行肯定还是会报错的。这里涉及到链接器怎样查找库进行载入的,链接器的默认搜索目录 /lib 和 /usr/lib,想加入别的目录需要往配置文件 /etc/ld.so.conf 中加入目录,调用 ldconfig 去刷新缓存,刷新出来的文件会进行排序并放到缓存文件 /etc/ld.so.cache,链接器载入动态库时就是从这个缓存文件中搜寻。任何时候往这些目录中添加新的库都需要调用 ldconfig 刷新缓存。Linux 还可以通过 LD_LIBRARY_PATH 环境变量添加动态库搜寻路径,以及在可执行文件中设置 rpath。遵循 rpath > LD_LIBRARY_PATH > /etc/ld.so.cache 的优先级顺序。此处的搜寻路径是专门用于程序载入动态库的,跟编译时的搜索路径没有关系,编译时 -L 选项不可少。

有了以上的描述,为了执行 test,可以选择将 libcalc_mean.so 放到系统可以找到的位置,如 /usr/lib 中,或者加入到 LD_LIBRARY_PATH,或者设置 rpath。为了设置 rpath,得加上 -Wl,-rpath=.,得到如下方式的编译命令:

gcc -o test main.c -lcalc_mean -L. -Wl,-rpath=.

而设置 LD_LIBRARY_PATH 则用 export LD_LIBRARY_PATH=.:$LD_LIBRARY_PATH 来添加。rpath 的好处在于 rpath 是嵌入到可执行文件的内部的,不依赖于环境,但是也缺乏灵活性。

编译的几个步骤

从上面的描述可以应付几乎所有的编译、链接的问题了,也有助于排查各种问题。接下来的内容对于喜欢了解的人是有帮助的,虽然这些内容不实际对工作带来帮助,但了解是有益处的。整个程序的编译过程分为预处理、编译、汇编和链接。如下图所示:

compile-stage

hello.c

#include <stdio.h>

int mian() {
    printf("Hello, World!\n");
    return 0;
}

cpp hello.c > hello.i

预处理处理所有预处理命令,包括展开宏,包含文件等。

gcc -S hello.i

由编译器将文件编译为汇编代码,这个文件是 hello.s

as -o hello.o hello.s

汇编器将汇编代码编译为 ELF 文件的机器代码。以上步骤可以通过一步:gcc -c hello.c 完成就可以生成机器码 .o 文件。此时的机器码还不能运行,因为是每个文件生成一个 .o 文件,而且引用的库还没有链接进来,意味着外部引用符号还没有得到解析。

ld -o hello hello.o ...library...

必须使用 ld 程序将所有的 .o 文件和库代码链接在一起,几乎所有程序都会用标准 C 库,所以这个库是必须指定的。默认情况下 gcc 会帮我们做好,如果自己手动调用 ld 就需要自己手动指定了。

以上整个过程只要用 gcc -o hello hello.c 完成,为了观察整个编译过程使用 gcc --verbose -o hello hello.c 命令即可。

有用的工具

在 gcc 编译过程指定 -Dname=value 可以定义宏,这个宏将会在预处理阶段被展开,并指定对应的条件预处理。

nm 程序可以查看 ELF 文件的符号表。ldd 可以查看可执行文件链接的动态库,以及在当前环境下搜索到它们的位置。有兴趣就自己去查阅对应的文档。